<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
    <meta http-equiv="Pragma" content="no-cache">
    <meta http-equiv="Expires" content="0">
    <title>WLED Custom Palette Editor</title>
    <script type="text/javascript">
      var d = document;
      function gId(e) {return d.getElementById(e);}
      function cE(e) {return d.createElement(e);}
    </script>
  	<script src="common.js" type="text/javascript"></script>

    <style>
      body {
        font-family: Arial, sans-serif;
        background-color: #111;
        font-size: 16px;
        color: #ddd;
        margin: 0 10px;
        line-height: 0.5;
      }
      #pCont {
        position: relative;
        width: 100%;
        height: 20px;
      }
      #bCont {
        position: absolute;
        margin-top: 50px;
      }
      #gBox {
        width: 100%;
        height: 100%;
      }
      .cMark, .cPickMark {
        position: absolute;
        border-radius: 3px;
        background-color: rgb(192, 192, 192);
        border: 2px solid rgba(68, 68, 68, 0.5);
        z-index: 2;
      }
      .cMark {
        height: 30px;
        width: 7px;
        top: 50%;
        transform: translateY(-50%);
        touch-action: none;
      }
      .cPickMark {
        height: 7px;
        width: 7px;
        top: 150%;
      }
      .dMark {
        position: absolute;
        height: 5px;
        width: 5px;
        border-radius: 3px;
        background-color: rgb(255, 255, 255);
        border: 3px solid rgb(155, 40, 40);
        top: 220%;
        z-index: 2;
      }
      .cPick {
        position: absolute;
        height: 1px;
        width: 1px;
        border: 1px;
        top: 150%;
        z-index: 1;
        border-color: #111;
        background-color: #111;
      }
      .btnCls {
        padding: 0;
        margin: 0;
        vertical-align: bottom;
        background-color: #111;
      }
      #bCont span {
        display: inline-flex;
        align-items: center;
        color: #fff;
        font-size: 12px;
        vertical-align: middle;
      }
      #info {
        text-align: center;
        color: #fff; 
        font-size: 12px;
        position: relative;
        margin-top: 10px;
        line-height: 1;
      }
      .wrap {
        width: 100%;
        margin: 0 auto;
      }
      @media (min-width: 800px) {
        .wrap {
          width: 800px;
        }
      }
      .pal {height: 20px;}
      .pGrads {flex: 1; height: 20px; border-radius: 3px;}
      .pMain {margin-top: 50px; width: 100%;}
      .pTop {height: fit-content; text-align: center; color: #fff; font-size: 14px; line-height: 1;}
      .pGradPar {display: flex; align-items: center; height: fit-content; margin-top: 10px; text-align: center; color: #fff; font-size: 12px; line-height: 1;}
      .btnsDiv {display: inline-flex; margin-left: 5px; width: 50px;}
      .sSpan, .eSpan {cursor: pointer;}
      h1 {font-size: 1.6rem;}
    </style>
  </head>
<body>
<div id="wrap" class="wrap">
  <div style="display: flex; justify-content: center;">
    <h1 style="display: flex; align-items: center;">
      <svg style="width: 36px; height: 36px; margin-right: 6px;" viewBox="0 0 32 32">
        <rect style="fill: #03F" x="6" y="22" width="8" height="4"/>
        <rect style="fill: #03F" x="14" y="14" width="4" height="8"/>
        <rect style="fill: #03F" x="18" y="10" width="4" height="8"/>
        <rect style="fill: #03F" x="22" y="6" width="8" height="4"/>
      </svg>
      <span id="head">WLED Palette Editor</span>
    </h1>
  </div>

  <div id="pCont"><div id="gBox"></div></div>
  <div style="display: flex; justify-content: center;">
    <div id="pals" class="pMain">
      <div id="distDiv" class="pTop"></div>
      <div id="memWarn" class="pTop" style="display:none; color:#ff6600; margin-bottom:8px; font-size:16px;">
        Warning: Adding many custom palettes might cause stability issues, create <a href="/settings/sec#backup" style="color:#ff9900">backups</a> before proceeding.</div>
      <div id="pTop" class="pTop">Custom palettes</div>
    </div>
  </div>

  <div style="display: flex; justify-content: center;">
    <div id="info">Click gradient to add. Box = color. Red = delete. Arrow = upload. Pencil = edit.</div>
  </div>
  <div style="display: flex; justify-content: center;">
    <div id="sPals" class="pMain">
        <div id="spTop" class="pTop">Static palettes</div>
    </div>
  </div>
</body>

<script type="text/javascript">
  // global vars
  var gBox = gId('gBox');     // gradientBox
  var cpalc = -1, cpalm = 10; // current palette count, max custom
  var pxCol = {};             // pixel color map
  var tCol = {};              // true color map
  var rect = gBox.getBoundingClientRect(); // bounding rect of gBox
  var gLen = rect.width;      // gradientLength
  var mOffs = Math.round((gLen / 256) / 2) - 5; // marker offset
  var palArr = [];            // paletteArray
  var palNm = [];             // paletteName

  var svgSave = '<svg style="width:25px;height:25px" viewBox="0 0 24 24"><path fill=#fff d="M22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12M7,12L12,17V14H16V10H12V7L7,12Z"/></svg>'
  var svgEdit = '<svg style="width:25px;height:25px" viewBox="0 0 24 24"><path fill=#fff d="M12,2C6.47,2 2,6.47 2,12C2,17.53 6.47,22 12,22C17.53,22 22,17.53 22,12C22,6.47 17.53,2 12,2M15.1,7.07C15.24,7.07 15.38,7.12 15.5,7.23L16.77,8.5C17,8.72 17,9.07 16.77,9.28L15.77,10.28L13.72,8.23L14.72,7.23C14.82,7.12 14.96,7.07 15.1,7.07M13.13,8.81L15.19,10.87L9.13,16.93H7.07V14.87L13.13,8.81Z"/></svg>'
  var svgDist = '<svg style="width:25px;height:25px" viewBox="0 0 24 24"><path fill=#fff d="M4 22H2V2H4V22M22 2H20V22H22V2M13.5 7H10.5V17H13.5V7Z"/></svg>'
  var svgTrash = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30px" height="30px"><path style="fill:#880000; stroke: #888888; stroke-width: -2px;stroke-dasharray: 0.1, 8;" d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>'

  const distDiv = gId("distDiv");
  distDiv.addEventListener('click', distrib);
  distDiv.setAttribute('title', 'Distribute equally');
  distDiv.innerHTML = svgDist;

  function recOf() {
    rect = gBox.getBoundingClientRect();
    gLen = rect.width;
    mOffs = Math.round((gLen / 256) / 2) - 5;
  }

  //Initiation
  getInfo();
  window.addEventListener('load', chkW);
  window.addEventListener('resize', chkW);

  gBox.addEventListener("click", clikGrad);

  //Sets start and stop, mandatory
  addC(0);
  addC(255);

  updGrad(); // updateGradient at startup

  function clikGrad(e) { // clickOnGradient
    rmTrash(e); // removeTrashcan
    addC(Math.round((e.offsetX/gLen)*256));
  }

  ///////// Add a new color marker
  function addC(tPos, thisCol = '') {
    let pos = -1;
    let exist = false;
    const cMarks = gBox.querySelectorAll('.cMark'); // color markers

    cMarks.forEach((cm) => {
      if (cm.getAttribute("data-tpos") == tPos) exist = true;
    });

    if (cMarks.length > 17) exist = true;
    if (exist) return;

    if (tPos > 0 && tPos < 255) {
      for (var i=1; i<=16 && pos<1; i++) {
        if (!gId("cMark"+i)) pos = i;
      }
    } else {
      pos = tPos;
    }
    if (thisCol == '') {
      thisCol = `#${(Math.random()*0xFFFFFF<<0).toString(16).padStart(6,'0')}`;
    }

    const cMark = cE('span'); // color marker
    cMark.className = 'cMark';
    cMark.id = 'cMark' + pos;
    cMark.setAttribute("data-tpos", tPos);
    cMark.setAttribute("data-tcol", thisCol);
    cMark.setAttribute("data-offset", mOffs);
    cMark.addEventListener('click', stopProp);
    cMark.style.left = `${Math.round((gLen/256)*tPos)+mOffs}px`;

    const cPick = cE('input'); // colorPicker
    cPick.type = 'color';
    cPick.value = thisCol;
    cPick.className = 'cPick';
    cPick.id = 'cPick' + pos;
    cPick.addEventListener('input', updGrad);
    cPick.addEventListener('click', cpClk);

    const cPM = cE('span'); // colorPickerMarker
    cPM.className = 'cPickMark';
    cPM.id = 'cPM' + pos;
    cPM.addEventListener('click', colClk);
    cPM.style.left = cMark.style.left;
    cPick.style.left = cMark.style.left;

    if (pos > 0 && pos < 255) {
      const dMark = cE('span'); // deleteMarker
      dMark.className = 'dMark';
      dMark.id = 'dMark' + pos;
      dMark.addEventListener('click', (e) => { delCol(e); });
      dMark.style.left = cMark.style.left;
      gBox.appendChild(dMark);
    }

    cMark.style.backgroundColor = cPick.value;
    cPM.style.backgroundColor = cPick.value;

    gBox.appendChild(cPick);
    gBox.appendChild(cMark);
    gBox.appendChild(cPM);
    if (pos > 0 && pos < 255) mkDrag(gId(cMark.id)); // makeMeDrag

    setTip(gId(cMark.id)); // setTooltipMarker
    updGrad();
  }

  ///////// Update Gradient
  function updGrad() { // updateGradient
    const cMarks = gBox.querySelectorAll('.cMark');
    pxCol = {};
    tCol = {};
    cMarks.forEach((cm) => {
      const cp = gId(cm.id.replace('cMark','cPick'));
      const col = cp.value;
      gId(cm.id.replace('cMark','cPM')).style.backgroundColor = col;
      cm.style.backgroundColor = col;
      cm.setAttribute("data-tcol", col);
      const tPos = cm.getAttribute("data-tpos");
      const gPos = Math.round((gLen/256)*tPos);
      pxCol[gPos] = col;
      tCol[tPos] = col;
    });
    let gStr = 'linear-gradient(to right';
    Object.entries(pxCol).forEach(([p,c]) => {
      gStr += `, ${c} ${p}px`;
    });
    gStr += ')';
    gBox.style.background = gStr;
  }

  function stopProp(e) { e.stopPropagation(); }

  function colClk(e) {
    rmTrash(e);
    e.stopPropagation();
    const src = e.target || e.srcElement;
    let cp = gId(src.id.replace("cPM","cPick"));
    cp.click();
  }

  function cpClk(e) {
    rmTrash(e);
    e.stopPropagation();
  }

  // make element draggable
  function mkDrag(el) { // makeMeDrag
    var posNew=0, mPos=0;
    var rect=gBox.getBoundingClientRect();
    var maxX=rect.right, minX=rect.left, gLen=maxX-minX+1;

    el.onmousedown=dragStart;
    el.ontouchstart=dragStart;

    function dragStart(e) {
      rmTrash(e);
      var isT=e.type.startsWith('touch');
      if (!isT) e.preventDefault();
      mPos=isT?e.touches[0].clientX:e.clientX;
      d.onmouseup=dragEnd; d.ontouchend=dragEnd; d.ontouchcancel=dragEnd;
      d.onmousemove=dragMove; d.ontouchmove=dragMove;
    }

    function dragMove(e) {
      var isT=e.type.startsWith('touch');
      if (!isT) e.preventDefault();
      var cX=isT?e.touches[0].clientX:e.clientX;
      posNew=mPos-cX; mPos=cX;
      var mInG=mPos-(minX+1);
      var tPos=Math.round((mInG/gLen)*256);
      var old=el.getAttribute("data-tpos");
      if (tPos>0 && tPos<255 && old!=tPos) {
        el.style.left=(Math.round((gLen/256)*tPos)+mOffs)+"px";
        gId(el.id.replace('cMark','cPM')).style.left=el.style.left;
        gId(el.id.replace('cMark','dMark')).style.left=el.style.left;
        gId(el.id.replace('cMark','cPick')).style.left=el.style.left;
        el.setAttribute("data-tpos",tPos);
        setTip(el);
        updGrad();
      }
    }

    function dragEnd() {
      d.onmouseup=null; d.ontouchend=null; d.ontouchcancel=null;
      d.onmousemove=null; d.ontouchmove=null;
    }
  }

  function setTip(el) { // setTooltipMarker
    el.setAttribute('title', `${el.getAttribute("data-tpos")} : ${el.getAttribute("data-tcol")}`);
  }

  function delCol(e) { // deleteColor
    var trash=cE("div");
    var dM=e.target || e.srcElement;
    var cM=gId(dM.id.replace("d","c"));
    var cPM=gId(dM.id.replace("dMark","cPM"));
    var cP=gId(dM.id.replace("dMark","cPick"));
    var rX=dM.getBoundingClientRect().x-10;
    var rY=dM.getBoundingClientRect().y+13;

    trash.id="trash";
    trash.innerHTML=svgTrash;
    trash.style.position="absolute";
    trash.style.left=rX+"px";
    trash.style.top=rY+"px";
    d.body.appendChild(trash);

    trash.addEventListener("click",()=>{
      trash.remove(); cM.remove(); cPM.remove(); cP.remove(); dM.remove();
      updGrad();
    });
    e.stopPropagation();
    d.addEventListener("click", rmTrash);
  }

  function rmTrash(e) { // removeTrashcan
    var t=gId("trash");
    if (t && e.target!=t) { t.remove(); d.removeEventListener("click", rmTrash);} 
  }

  function chkW() {
    const wrap=gId('wrap'); const head=gId('head');
    head.style.display=(wrap.offsetWidth<600)?'none':'inline';
  }

  function calcJSON() {
    let rStr='{"palette":[';
    Object.entries(tCol).forEach(([p,c],i)=>{
      if (i>0) rStr+=',';
      rStr+=`${p},"${c.slice(1)}"`;
    });
    rStr+=']}';
    return rStr;
  }

  function initUpload(i) {
    uploadJSON(calcJSON(), `/palette${i}.json`);
  }

  function uploadJSON(jsonString, fileName) {
    //Some indication on "I'm working"
    var req = new XMLHttpRequest();
    var blob = new Blob([jsonString], {type: "application/json"});
    req.addEventListener('load', ()=>{
      console.log(this.responseText, ' - ',  this.status)
      localStorage.removeItem('wledPalx');
      //setTimeout(()=>{
      //  ss.setAttribute('fill', '#fff');
      //}, 1000);
      //setTimeout(()=>{window.location.href='/';},2000);
      window.location.href = '/'; //Guessing we want to return ASAP when we get confirmation save is done
    });
    req.addEventListener('error', (e)=>{
      console.log('Error: ', e); console.log(' Status: ', this.status);
      //Show some error notification for some time
      setTimeout(()=>{
        //Remove it when time has passed
      }, 1000);
    });
    req.open("POST", "/upload");
    var formData = new FormData();
    formData.append("data", blob, fileName);
    req.send(formData);
    return false;
  }

  async function getInfo() {
		getLoc();
    try {
      var arr = [];
      const resInfo = await fetch(getURL('/json/info')); // fetch info (includes cpalcount and cpalmax)
      const resPals = await fetch(getURL('/json/pal'));  // fetch palette names
      const json = await resInfo.json();
      palNm = await resPals.json();
      cpalc = json.cpalcount;
      cpalm = json.cpalmax;
      fetchPals(cpalc-1);
    } catch (error) {
      console.error(error);
    }
  }

  async function fetchPals(lastPal) {
    palArr.length = 0;
    for (let i = 0; i <= lastPal; i++) {
      const url = getURL(`/palette${i}.json`);
      try {
        const response = await fetch(url);
        const json = await response.json();
        palArr.push(json);
      } catch (error) {
        cpalc--; //remove audio/dynamically generated palettes
        console.error(`Error fetching JSON from ${url}: `, error);
      }
    }
    //If there is room for more custom palettes, add an empty, gray slot
    if (palArr.length < cpalm) {
      //Room for one more :)
      palArr.push({"palette":[0,70,70,70,255,70,70,70]});
    }

    //Get static palettes from localStorage and do some magic to reformat them into the same format as the palette JSONs
    //This code excludes any objects with "non valid integer colors", i.e. r, c1, c2, c3 and such
    //This code also fixes potentially broken palettes which doesn't end on 255
    //The code finally also removes any representations of the custom palettes, since we read them from file

    const wledPalx = JSON.parse(localStorage.getItem('wledPalx'));
    if (!wledPalx) {
      alert("Palette cache missing from browser. Return to main page first.","Missing cache!")
    } else {
      for (const key in wledPalx.p) {
        wledPalx.p[key].name = palNm[key];
        if (key > 255-cpalm) {
          delete wledPalx.p[key]; // remove custom palettes
          continue;
        }
        const arr = wledPalx.p[key];
        let valid = true;
        for (const subArr of arr) {
          if (!Array.isArray(subArr) || subArr.length !== 4) {
            valid = false;
            break;
          }
          for (const val of subArr) {
            if (typeof val !== 'number' || val < 0 || val > 255 || !Number.isInteger(val)) {
              valid = false;
              break;
            }
          }
        }
        if (!valid) {
          delete wledPalx.p[key];
          continue;
        }
        const lastArr = arr[arr.length - 1];
        if (lastArr[0] !== 255) {
          const copyArr = [...lastArr];
          copyArr[0] = 255;
          arr.push(copyArr);
        }
      }

      const pArray = Object.entries(wledPalx.p).map(([key, value]) => ({
        [key]: value.flat(),
        name: value.name
      }));
      // Sort pArray by name
      pArray.sort((a, b) => a.name.localeCompare(b.name));

      palArr.push( ...pArray);
    }
    genPalDivs();
  }

  function genPalDivs() {
    const palsDiv = gId("pals");
    const sPalsDiv = gId("sPals");
    const memWarn = gId("memWarn");
    const palDivs = Array.from(palsDiv.children).filter((child) => {
      return /^pal\d+$/.test(child.id); // match ids "pal" followed by one or more digits
    });

    for (const div of palDivs) {
      palsDiv.removeChild(div); // remove each div that matches the above selector
    }

    memWarn.style.display = (cpalc >= 10) ? 'block' : 'none'; // Show/hide memory warning based on custom palette count

    for (let i = 0; i < palArr.length; i++) {
      const pal = palArr[i];
      const palDiv = cE("div");
      palDiv.id = `pal${i}`;
      palDiv.classList.add("pal");
      const thisKey = Object.keys(pal)[0];
      palDiv.dataset.colarray = JSON.stringify(pal[thisKey]);

      const gradDiv = cE("div");
      gradDiv.id = `pGrad${i}`
      const btnsDiv = cE("div");
      btnsDiv.id = `btns${i}`;
      btnsDiv.classList.add("btnsDiv")

      const sSpan = cE("span");
      sSpan.id = `s${i}`;
      sSpan.onclick = function() {initUpload(i)};
      sSpan.setAttribute('title', `Send current editor to slot ${i}`); // perhaps Save instead of Send?
      sSpan.innerHTML = svgSave;
      sSpan.classList.add("sSpan")
      const eSpan = cE("span");
      eSpan.id = `e${i}`;
      eSpan.onclick = function() {loadEdit(i)};
      eSpan.setAttribute('title', `Copy slot ${i} to editor`);
      if (palArr[i].name) {
        eSpan.setAttribute('title', `Copy ${palArr[i].name} to editor`);
      }
      eSpan.innerHTML = svgEdit;
      eSpan.classList.add("eSpan")

      gradDiv.classList.add("pGrads");
      let gCols = "";

      for (let j = 0; j < pal[thisKey].length; j += 2) {
        const pos = pal[thisKey][j];
        if (typeof(pal[thisKey][j+1]) === "string") {
          gCols += `#${pal[thisKey][j+1]} ${pos/255*100}%, `;
        } else {
          const r = pal[thisKey][j + 1];
          const g = pal[thisKey][j + 2];
          const b = pal[thisKey][j + 3];
          gCols += `rgba(${r}, ${g}, ${b}, 1) ${pos/255*100}%, `;
          j += 2;
        }
      }

      gCols = gCols.slice(0, -2); // remove the last comma and space
      gradDiv.style.backgroundImage = `linear-gradient(to right, ${gCols})`;
      palDiv.className = "pGradPar";
      if (thisKey == "palette") {
        btnsDiv.appendChild(sSpan); //Only offer to send to custom palettes
      } else{
        eSpan.style.marginLeft = "25px";
      }
      if (i!=cpalc) {
        btnsDiv.appendChild(eSpan); //Dont offer to edit the empty spot
      }
      palDiv.appendChild(gradDiv);
      palDiv.appendChild(btnsDiv);
      if (thisKey == "palette") {
        palsDiv.appendChild(palDiv);
      } else {
        sPalsDiv.appendChild(palDiv);
      }
    }
  }

  function loadEdit(i) {
    d.querySelectorAll('input[id^="cPick"]').forEach((input) => {
      input.parentNode.removeChild(input);
    });
    d.querySelectorAll('span[id^="cMark"], span[id^="cPM"], span[id^="dMark"]').forEach((span) => {
      span.parentNode.removeChild(span);
    });

    let colArr = JSON.parse(gId(`pal${i}`).getAttribute("data-colarray"));

    for (let j = 0; j < colArr.length; j += 2) {
      const pos = colArr[j];
      let hex;
      if (typeof(colArr[j+1]) === "string") {
        hex = `#${colArr[j+1]}`;
      } else {
        const r = colArr[j + 1];
        const g = colArr[j + 2];
        const b = colArr[j + 3];
        hex = rgbToHex(r, g, b);
        j += 2;
      }
      addC(pos, hex);
      window.scroll(0, 0);
    }
  }

  function distrib() {
    let cMarks = [...gBox.querySelectorAll('.cMark')];
    cMarks.sort((a, b) => a.getAttribute('data-tpos') - b.getAttribute('data-tpos'));
    cMarks = cMarks.slice(1, -1);
    const spacing = Math.round(256 / (cMarks.length + 1));

    cMarks.forEach((e, i) => {
      const mId = e.id.match(/\d+/)[0];
      const tCol = e.getAttribute("data-tcol");
      gBox.removeChild(e);
      gBox.removeChild(gId(`cPick${mId}`));
      gBox.removeChild(gId(`cPM${mId}`));
      gBox.removeChild(gId(`dMark${mId}`));
      addC(spacing * (i + 1), tCol);
    });
  }

  function rgbToHex(r, g, b) {
    const hex = ((r << 16) | (g << 8) | b).toString(16);
    return "#" + "0".repeat(6 - hex.length) + hex;
  }

</script>
</html>
